Анализ данных сервиса "Ненужные вещи"¶

Цель исследования¶

Я - аналитик сервиса “Ненужные вещи”. Менеджер проекта занимается его продвижением, ему интересно провести ряд экспериментов. Для этого ему необходимо понять какая аудитория у сервиса, c этим запросом он обратился ко мне

Необходимо провести анализ поведения пользователей в мобильном приложении для последующего управления вовлеченностью и получении ряда гипотез

Основные вопросы:

  • какие сценарии использования приложения выделяются?
  • как различается время между поиском и открытием объявления у пользователей, совершающих и не совершающих целевое действие (просмотр контакта)

Ссылка на дашборд

Итоговая презентация

В рамках исследования стоят следующие задачи:

  • провести предобработку данных
  • исследовать данные
    • время активности пользователей
    • действия пользователей
    • источники, с которых пользователи устанавливают приложение
    • средняя длина сессии
    • количество карточек, которые пользователи посмотрели за сессию
    • DAU и WAU
    • действия, на которых пользователи проводят больше всего времени
  • ответить на основные вопросы исследования
    • построить и проанализировать диаграмму Sankey
    • найти самые популярные сценарии, которые приводят к целевому действию
    • построить воронки по популярным сценариям
    • проанализировать как различается время между поиском и открытием объявления у пользователей, совершающих и не совершающих целевое действие
  • проверить ряд гипотез
    • конверсия в просмотры контактов различается у пользователей, совершающих действия tips_show и tips_click и пользователей, совершающих только tips_show
    • конверсия в просмотры контактов у пользователей с действием favorites_add иная, чем у пользователей, не совершивших это действие
    • конверсия в просмотры контактов различается у пользователей, установивших приложения из Yandex и установивших из Google

В рамках проекта мы будем использовать два датасета.

  1. mobile_dataset.csv

Колонки в mobile_sources.csv:

  • userId — идентификатор пользователя,
  • source — источник, с которого пользователь установил приложение.
  1. mobile_sources.csv

Колонки в mobile_dataset.csv:

  • event.time — время совершения,
  • user.id — идентификатор пользователя,
  • event.name — действие пользователя.

Виды действий:

  • advert_open — открыл карточки объявления,
  • photos_show — просмотрел фотографий в объявлении,
  • tips_show — увидел рекомендованные объявления,
  • tips_click — кликнул по рекомендованному объявлению,
  • contacts_show и show_contacts — посмотрел номер телефона,
  • contacts_call — позвонил по номеру из объявления,
  • map — открыл карту объявлений,
  • search_1—search_7 — разные действия, связанные с поиском по сайту,
  • favorites_add — добавил объявление в избранное.
In [1]:
#импортируем необходимые библиотеки
!pip install requests

import requests 
import math as mth
import numpy as np
import pandas as pd
import seaborn as sns
import plotly.express as px
from scipy import stats as st
from bs4 import BeautifulSoup 
import plotly.graph_objs as go
from tqdm.notebook import tqdm
import matplotlib.pyplot as plt
from datetime import datetime, timedelta


import warnings
warnings.filterwarnings("ignore")
Requirement already satisfied: requests in c:\users\belle\anaconda3\lib\site-packages (2.27.1)
Requirement already satisfied: idna<4,>=2.5 in c:\users\belle\anaconda3\lib\site-packages (from requests) (3.3)
Requirement already satisfied: charset-normalizer~=2.0.0 in c:\users\belle\anaconda3\lib\site-packages (from requests) (2.0.4)
Requirement already satisfied: urllib3<1.27,>=1.21.1 in c:\users\belle\anaconda3\lib\site-packages (from requests) (1.26.9)
Requirement already satisfied: certifi>=2017.4.17 in c:\users\belle\anaconda3\lib\site-packages (from requests) (2021.10.8)
In [2]:
sources = pd.read_csv('https://code.s3.yandex.net/datasets/mobile_sources.csv') 
session = pd.read_csv('https://code.s3.yandex.net/datasets/mobile_dataset.csv')

Предобработка данных ¶

Подготовим столбец session¶

In [3]:
#приведем названия столбцов к змеиному регистру
session.columns = session.columns.str.replace('.', '_')
In [4]:
session.head(5)
Out[4]:
event_time event_name user_id
0 2019-10-07 00:00:00.431357 advert_open 020292ab-89bc-4156-9acf-68bc2783f894
1 2019-10-07 00:00:01.236320 tips_show 020292ab-89bc-4156-9acf-68bc2783f894
2 2019-10-07 00:00:02.245341 tips_show cf7eda61-9349-469f-ac27-e5b6f5ec475c
3 2019-10-07 00:00:07.039334 tips_show 020292ab-89bc-4156-9acf-68bc2783f894
4 2019-10-07 00:00:56.319813 advert_open cf7eda61-9349-469f-ac27-e5b6f5ec475c
In [5]:
session.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 74197 entries, 0 to 74196
Data columns (total 3 columns):
 #   Column      Non-Null Count  Dtype 
---  ------      --------------  ----- 
 0   event_time  74197 non-null  object
 1   event_name  74197 non-null  object
 2   user_id     74197 non-null  object
dtypes: object(3)
memory usage: 1.7+ MB

В session 74197 строк и 3 столбца:

  • event_time - время совершения действия
  • event_name - действие пользователя
  • user_id - идентификатор пользователя

В датасете нет пропусков

Все столбцы имеют тип object

In [6]:
#изменим тип столбца, где отражается время
session['event_time'] = pd.to_datetime(session['event_time'])
In [7]:
print(session.duplicated().sum()) #проверим наличие дубликатов
print(session['event_name'].unique()) #проверим столбец на наличие неявных дубликатов
0
['advert_open' 'tips_show' 'map' 'contacts_show' 'search_4' 'search_5'
 'tips_click' 'photos_show' 'search_1' 'search_2' 'search_3'
 'favorites_add' 'contacts_call' 'search_6' 'search_7' 'show_contacts']

В датасете дубликатов нет.

В столбце sources неявных дубликатов нет, но есть два одинаковых дейтсвия - contacts_show и show_contacts. Объеденим их в одно.

In [8]:
session['event_name'] = session['event_name'].replace('contacts_show', 'show_contacts')

Подготовим столбец sources¶

In [9]:
#приведем названия столбцов к змеиному регистру
sources = sources.rename(columns={'userId':'user_id'})
In [10]:
sources.head(5)
Out[10]:
user_id source
0 020292ab-89bc-4156-9acf-68bc2783f894 other
1 cf7eda61-9349-469f-ac27-e5b6f5ec475c yandex
2 8c356c42-3ba9-4cb6-80b8-3f868d0192c3 yandex
3 d9b06b47-0f36-419b-bbb0-3533e582a6cb other
4 f32e1e2a-3027-4693-b793-b7b3ff274439 google
In [11]:
sources.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 4293 entries, 0 to 4292
Data columns (total 2 columns):
 #   Column   Non-Null Count  Dtype 
---  ------   --------------  ----- 
 0   user_id  4293 non-null   object
 1   source   4293 non-null   object
dtypes: object(2)
memory usage: 67.2+ KB

В sources 4293 строк и 2 столбца:

  • user_id - время совершения действия
  • source - действие пользователя

В датасете нет пропусков

Все столбцы имеют тип object

In [12]:
print(session.duplicated().sum()) #проверим на наличие дубликатоd
print(sources['source'].unique()) #проверим столбец на наличие неявных дубликатов
0
['other' 'yandex' 'google']

В датасете дубликатов нет, в столбце sources неявных дубликатов тоже нет

Выделим сессии пользователей¶

Установим значение в 20 минут между двумя соседними действиями, это и будет тайм-аутом сессии

In [13]:
#отсортируем датасет по дате и уникальным пользователям
session = session.sort_values(['user_id', 'event_time']) 

# определим нумерацию сессий, значение будет меняться если время между действиями отличается больше, чем на 20 минут
g = (session.groupby('user_id')['event_time'].diff() > pd.Timedelta('20Min')).cumsum()

#присвоим эту нумерацию в столбец `session_id`
session['session_id'] = session.groupby(['user_id', g], sort=False).ngroup() + 1
In [14]:
session.tail(2)
Out[14]:
event_time event_name user_id session_id
72688 2019-11-03 16:08:18.202734 tips_show fffb9e79-b927-4dbb-9b48-7fd09b23a62b 10975
72689 2019-11-03 16:08:25.388712 tips_show fffb9e79-b927-4dbb-9b48-7fd09b23a62b 10975

В итоге вышло 10975 сессий

Вывод.

В рамках проекта будем работать с двумя датасетами - session и sources

  1. session

    • дубликатов и пропусков выявлено не было
    • изменили тип данных на временной
    • объеденили два идентичных действия в одно (show_contacts)
  2. sources

    • дубликатов и пропусков выявлено не было

Для дальнейшей работы выделили и добавили столбец с номерами сессий, разницу взяли в 20 минут

Исследовательский анализ данных ¶

Исследуем время активности пользователей¶

Проведем анализ предоставленных даных. Для начала посмотрим данные за какой период у нас есть

In [15]:
session.head(3)
Out[15]:
event_time event_name user_id session_id
805 2019-10-07 13:39:45.989359 tips_show 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 1
806 2019-10-07 13:40:31.052909 tips_show 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 1
809 2019-10-07 13:41:05.722489 tips_show 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 1
In [16]:
print(f'В таблице данные с {session["event_time"].min()} по {session["event_time"].max()}')
В таблице данные с 2019-10-07 00:00:00.431357 по 2019-11-03 23:58:12.532487
In [17]:
#построим гистограмму по дате и времени

plt.figure(figsize=(15,5))
session['event_time'].hist(bins=1000, color='#668567')
plt.title('Время активности пользователей в приложении')
plt.xlabel('Дата и время') 
plt.ylabel('Частота')
plt.show()

Данные предоставлены за период с 9 октября по 5 ноября. Распределены они примерно одинаково, видно, что пики и спады происходят с похожим интервалом.

Приблизим график чтобы посмотреть время активности пользователей. Построим для сравнения два графика по случано выбранными датами

In [18]:
plt.figure(figsize=(15,12))


plt.subplot(2,1,1)
session['event_time'].hist(bins=1000, color='#668567')
plt.xlim(pd.datetime(2019,10,13), pd.datetime(2019,10,15))
plt.title('Время активности пользователей в приложении с 13 по 15 октября 2019 года')
plt.xlabel('Дата и время') 
plt.ylabel('Частота')

plt.subplot(2,1,2)
session['event_time'].hist(bins=1000, color='#d9e9a9')
plt.xlim(pd.datetime(2019,10,25), pd.datetime(2019,10,27))
plt.title('Время активности пользователей в приложении с 25 по 27 октября 2019 года')
plt.xlabel('Дата и время') 
plt.ylabel('Частота')

plt.show()

Графики схожи между собой. Пользователи проявляли меньше всего активности в ночное время - с 24.00 до 8.00.

Конкретного пика активности днем выделить тяжело. Пользователи с разной частотой заходят в приложение с 12 до 24.00. Такое поведение было ожидаемо, аномальных значений нет.

Исследуем Retention Rate - показатель удержания пользователей¶

Необходимо узнать как долго пользователи взаимодействовали с приложением, для этого построим таблицу удержания.

In [19]:
#подготовим таблицу для рассчета
retention = session.copy()

#построим сводную таблицу, где найдем время первого посещения
x = retention.pivot_table(index='user_id', values='event_time', aggfunc='first').reset_index()

#переименуем колонки
x.columns=['user_id', 'first']

#объеденим две таблицы
retention = retention.merge(x, on='user_id')

#выделим столбец с датой
retention['dt'] = retention['first'].dt.date

#оставим только одно действие в рамках сессии
retention =retention.loc[~retention['session_id'].duplicated()]

Напишем фукнцию, которая строит таблицу удержания

In [20]:
def retention_rate(df, horizon):
    '''
    функция строит таблицу удержания
    
    input: датасет, значение горизонта анализа
    output: таблица удержания пользователей
    '''
    
    # вычисляем лайфтайм для каждой сессии в днях
    df['lifetime'] = (df['event_time'] - df['first']).dt.days

    # строим таблицу удержания
    result_grouped = df.pivot_table(
        index=['dt'],
        columns='lifetime', 
        values='user_id', 
        aggfunc='nunique')

    # вычисляем размеры когорт
    cohort_sizes = (df.groupby('dt').agg({'user_id': 'nunique'})
        .rename(columns={'user_id': 'cohort_size'}))
    

    # объединяем размеры когорт и таблицу удержания
    result_grouped = cohort_sizes.merge(result_grouped, on='dt', how='left').fillna(0)

    # делим данные таблицы удержания на размеры когорт
    result_grouped = result_grouped.div(result_grouped['cohort_size'], axis=0)

    # исключаем из результата все лайфтаймы, превышающие горизонт анализа
    result_grouped = result_grouped[['cohort_size'] + list(range(horizon))]

    result_grouped['cohort_size'] = cohort_sizes

    return result_grouped
In [21]:
#применим фукнцию
rent_r = retention_rate(retention, 15)
rent_r.head()
Out[21]:
cohort_size 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14
dt
2019-10-07 204 1.0 0.117647 0.093137 0.107843 0.053922 0.034314 0.058824 0.078431 0.053922 0.049020 0.049020 0.034314 0.024510 0.058824 0.049020
2019-10-08 167 1.0 0.161677 0.125749 0.053892 0.041916 0.065868 0.053892 0.071856 0.053892 0.035928 0.059880 0.023952 0.029940 0.029940 0.035928
2019-10-09 176 1.0 0.073864 0.062500 0.056818 0.068182 0.068182 0.056818 0.073864 0.056818 0.034091 0.022727 0.028409 0.034091 0.056818 0.028409
2019-10-10 174 1.0 0.086207 0.103448 0.080460 0.091954 0.080460 0.068966 0.051724 0.080460 0.034483 0.034483 0.051724 0.045977 0.040230 0.034483
2019-10-11 136 1.0 0.088235 0.117647 0.095588 0.088235 0.073529 0.066176 0.044118 0.014706 0.044118 0.036765 0.036765 0.051471 0.029412 0.044118
In [22]:
#построим тепловую карту по удержанию
plt.figure(figsize=(15,5))

sns.heatmap(rent_r.drop('cohort_size', axis=1))
plt.title('Тепловая карта удержания пользователей')
plt.xlabel('Лайфтайм')
plt.ylabel('День первого посещения')
plt.show()

Тепловая карта читается плохо, но хорошо видно, что маленький процент людей вовзращается в приложение

In [23]:
print('Доля пользователей на следующий день')
rent_r[1].describe()
Доля пользователей на следующий день
Out[23]:
count    28.000000
mean      0.105909
std       0.032922
min       0.000000
25%       0.088385
50%       0.100929
75%       0.128095
max       0.170370
Name: 1, dtype: float64
In [24]:
print('Доля пользователей через неделю')
rent_r[7].describe()
Доля пользователей через неделю
Out[24]:
count    28.000000
mean      0.045373
std       0.031044
min       0.000000
25%       0.018519
50%       0.049511
75%       0.070512
max       0.093168
Name: 7, dtype: float64

На следующий день в приложение заходит около 10% пользователей, на 7 день остается около 5%. Показатели небольшие, пользователи редко заходят в приложение повторно

Исследуем пользователей¶

In [25]:
print(session['user_id'].nunique(), 'уникальных пользователей')  
4293 уникальных пользователей
In [26]:
#количество сессий у каждого пользователя
num_of_visits = session.pivot_table(index='user_id', values='session_id', aggfunc='nunique')

plt.figure(figsize=(15,5))

plt.subplot(1,2,1)
num_of_visits.boxplot('session_id')
plt.title('Количество сессий у пользователей (полный масштаб)')
plt.ylabel('Количество сессий') 

plt.subplot(1,2,2)
num_of_visits.boxplot('session_id')
plt.ylim(0,8)
plt.title('Количество сессий у пользователей(приближенный график)')
plt.ylabel('Количество сессий')

plt.show()

Всего в датасете 4293 уникальных пользователя

За период с 9 октября по 5 ноября 75% пользователей совершили до 6 сессий, медиана - 1

Довольно много выбросов. Есть пользователи, которые за эти 28 дней совершили больше 40 сессий, один из них - больше ста.

Исследуем действия пользователей¶

In [27]:
#посмотрим количество совершаемых действий
num_of_event = pd.DataFrame(session['event_name'].value_counts()).reset_index()

#переименуем столбцы
num_of_event.columns=['event', 'count'] 

num_of_event.head(3)
Out[27]:
event count
0 tips_show 40055
1 photos_show 10012
2 advert_open 6164

Для удобства анализа все поисковые действия объеденим в одно

In [28]:
#составим датасет, где только поисковые действия
search = num_of_event[num_of_event['event'].str.contains("search")]

#Удалим все строки с search из начального датасета
num_of_event = num_of_event[~num_of_event.event.str.contains("search")]

#добавим строку search в начальный датасет с общей суммой действий
num_of_event.loc[len(num_of_event.event)] = ['search', search['count'].sum()]

#отсортируем
num_of_event = num_of_event.sort_values(by='count', ascending=False)
In [29]:
plt.figure(figsize=(15,5))
plt.bar(num_of_event['event'], num_of_event['count'], color='#668567')

plt.title('Cуммарное количество событий')
plt.xlabel('Действия пользователей') 
plt.ylabel('Количество')
plt.xticks(rotation=45)
plt.show()

Чаще всего пользователи видят рекомендованные объявления.

Далее по популярности просмотр фотографий в объявлениях и суммарное количество всех поисковых действий.

Следом открытие карточки объявления, просмотр номера телефона, просмотр карты, добавление в избранное, звонок по номеру

Исследуем источники, с которых пользователи установливают приложение¶

In [30]:
source = sources['source'].value_counts() #датасет с количеством пользователей из каждого источника

plt.figure(figsize=(15,5))

plt.pie(source, autopct='%1.0f%%', colors=('#668567', '#95b88e', '#d9e9a9'), labels=source.index)
plt.title('Количество пользователей по источникам')

plt.show()

Как итог, 45% пользователей установили приложение из Yandex, 26% из Google и 29% из других источников

Исследуем average session length (среднюю длину сессии)¶

Перед анализом стоит сделать небольшую оговорку. Неизвестно в какое время пользователь вышел из приложения, поэтому то время, которое пользователь потратил на последнее действие в расчет не пойдет.

Построим сводную таблицу. В рамках каждой сессии найдем самое раннее и самое позднее время (время входа и выхода из приложения), в отдельном столбце посчитаем разницу между ними

In [31]:
asl = session.pivot_table(index='session_id', values='event_time', aggfunc=[min, max]) #сделали сводную таблицу
asl.columns=['time_entrance', 'time_exit'] #переименовали столбцы
asl['duration'] = asl['time_exit'] - asl['time_entrance'] #нашли разницу во времени
asl.sample(3)
Out[31]:
time_entrance time_exit duration
session_id
3568 2019-10-17 13:34:19.809963 2019-10-17 13:34:19.809963 0 days 00:00:00
5589 2019-10-19 23:08:08.284510 2019-10-19 23:28:39.641573 0 days 00:20:31.357063
3052 2019-10-20 13:52:36.495991 2019-10-20 13:52:42.645102 0 days 00:00:06.149111
In [32]:
print(f'Среднее время пребывания в приложении - {asl["duration"].mean()}, медиана - {asl["duration"].median()}')
Среднее время пребывания в приложении - 0 days 00:10:48.680013362, медиана - 0 days 00:04:55.340814

Медиана продолжительности сессии около 5 минут (4:55), в то время как среднее значение - почти 11 (10:49) минут. То есть очень сильный разброс по длительности сессий

In [33]:
asl['duration'] = asl['duration'] / np.timedelta64(1, 'm') #продолжительность сессии переведем в минуты и секунды

plt.figure(figsize=(15,5))

plt.subplot(1,2,1)
asl.boxplot('duration')
plt.title('Продолжительность сессий (полный масштаб)')
plt.ylabel('Продолжительность (минуты)') 

plt.subplot(1,2,2)
asl.boxplot('duration')
plt.ylim(0, 20)
plt.title('Продолжительность сессий (приближенный график)')
plt.ylabel('Продолжительность (минуты)')  

plt.show()

75% сессий продолжаются до 15 минут, медиана - 5 минут. Есть большое количество аномальных значений, из-за них среднее значение сильно отличается от медианы

Исследуем сколько карточек успевает просмотреть (advert_open) пользователь за одну сессию¶

In [34]:
#оставим в датасете строки только с действием 'advert_open'
adv_open = session.query('event_name =="advert_open"')

#посчитаем cколько действий совершали в рамках каждой сессии
adv_open = adv_open.pivot_table(index='session_id', values='event_name', aggfunc='count')

print(f'В среднем за сессию пользователь успевает посмотреть {round(adv_open["event_name"].mean(),1)} карточек, медиана - {adv_open["event_name"].median()}')
В среднем за сессию пользователь успевает посмотреть 4.8 карточек, медиана - 3.0
In [35]:
plt.figure(figsize=(16,5))

plt.subplot(1,2,1)
adv_open.boxplot('event_name')
plt.title('Количество просмотренных карточек за сессию (полный масштаб)')
plt.ylabel('Количество карточек') 

plt.subplot(1,2,2)
adv_open.boxplot('event_name')
plt.ylim(0, 12)
plt.title('Количество просмотренных карточек за сессию (приближенный график)')
plt.ylabel('Количество карточек')   

plt.show()

75% пользователей просматривают от 1 до 5 карточек за сессию

Иccледуем DAU и WAU¶

DAU (daily аctive users) - количество уникальных пользователей в день

WAU (weekly аctive users) - количество уникальных пользователей в неделю

Посмотрим сколько пользователей заходило каждый день в приложение. Раннее уже посчитали,что всего 4293 пользователя заходило в приложение за 28 дней

In [36]:
#добавим столбцы, в которых выделим дату посещения и неделю соответсвенно
session['date'] = session['event_time'].dt.date
session['week'] = session['event_time'].dt.week

#для каждого дня и для каждой недели посчитаем сколько было уникальных пользователей
dau = session.pivot_table(index='date', values='user_id', aggfunc='nunique').reset_index()
wau = session.pivot_table(index='week', values='user_id', aggfunc='nunique').reset_index()

#выведем статистику по количеству пользователей за день
dau['user_id'].describe()
Out[36]:
count     28.000000
mean     279.178571
std       46.737291
min      178.000000
25%      238.250000
50%      292.500000
75%      310.500000
max      352.000000
Name: user_id, dtype: float64
In [37]:
plt.figure(figsize=(15,5))
plt.bar(dau['date'], dau['user_id'], color='#668567')

plt.title('Количество уникальных пользователей в день')
plt.xlabel('Дата') 
plt.ylabel('Уникальные пользователи')
plt.show()

В среднем в день заходят 279 человек, медиана - 292. Минимальное количество пользователей за день было 178, максимальное - 352.

После 13 октября количество пользователей в день варьируется около 300.

В первые дни самые низкие показатели, дальше они растут. Интересно исследовать какие внешние или внутренние факторы могли на это повлиять. Возможно, это успешная реклама или интересные акции.

Посчитаем как меняется активность пользователей в выходные дни. Будем считать, что в основной рынок - российский, и выходные приходятся на субботу и воскресенье.

Посчитаем сколько уникальных пользователей заходят каждый день в выходные и будни. Заранее стоит отметить, что данных о будних днях больше, следовательно разброс может быть больше

In [38]:
#в dau переведм поменяем тип столбца с датой
dau['date'] = pd.to_datetime(dau['date'], errors='coerce')

#выделим номер недели
dau['weekday'] = dau['date'].dt.weekday

#добавим столбец. Если день недели равен 5 или 6, то будет значение weekends, в остальных случаях workday
dau['type'] = dau['weekday'].apply ( lambda x: 'weekends' if x in [5,6] else 'workday')
In [39]:
plt.figure(figsize=(15,5))

sns.boxplot(data=dau, x="user_id", y="type", color='#d9e9a9')

plt.title('Количество уникальных пользователей')
plt.xlabel('Количество пользователей')
plt.ylabel('Тип дня')
plt.show()

В рабочие дни больше пользователей заходят в приложение, нежели в выходные. Разница в медиане количества пользователей состаляет около 30 человек

Посчитаем сколько пользователей заходило каждую неделю (WAU)

In [40]:
#выведем статистику по количеству пользователей за день
wau['user_id'].describe()
Out[40]:
count       4.000000
mean     1382.500000
std       177.661663
min      1130.000000
25%      1344.500000
50%      1427.000000
75%      1465.000000
max      1546.000000
Name: user_id, dtype: float64
In [41]:
plt.figure(figsize=(15,5))

plt.bar(wau['week'], wau['user_id'], color='#668567')

plt.title('Количество уникальных пользователей в неделю')
plt.xlabel('Номер недели') 
plt.ylabel('Уникальные пользователи')
plt.show()

Для анализа диаграммы необходимо убедиться, что нет обрезанных недель (во всех столбцах дни с понедельника по воскресенье).

Посмотрим с какого дня недели предоставлены данные и каким днем они заканчиваются.

In [42]:
#выделим номер недели
session['weekday'] = session['event_time'].dt.weekday

display(session.loc[session['event_time'] == session['event_time'].min()])
display(session.loc[session['event_time'] == session['event_time'].max()])
event_time event_name user_id session_id date week weekday
0 2019-10-07 00:00:00.431357 advert_open 020292ab-89bc-4156-9acf-68bc2783f894 79 2019-10-07 41 0
event_time event_name user_id session_id date week weekday
74196 2019-11-03 23:58:12.532487 tips_show 28fccdf4-7b9e-42f5-bc73-439a265f20e9 1940 2019-11-03 44 6

Данные начинаются с понедельника и заканчиваются воскресеньем, значит во всех столбцах информация за полных 7 дней.

Для анализа предоставлены 4 недели - с 41 по 44.

Среднее количество уникальных пользователей в неделю - 1382, медиана - 1427. Минимальное количество за неделю было 1130, максимальное - 1546

Диаграмма чем-то напоминает на предыдущую. С 41 по 43 неделю она идет по возрастающей, на 44 значение немного уменьшается. Но 41 неделя выделяется низкими показателями, лучший показатель на 43.

Посчитаем Sticky Factor (степень липкости), этот показатель характеризует регулярность использования приложения в течении недели или месяца.

Расчет будет примерный, будем брать медианное значение по каждому показателю. За MAU возьмем сколько уникальных пользователей зашло в приложение за 28 дней.

In [43]:
dau = dau['user_id'].median()
wau = wau['user_id'].median()
mau = session['user_id'].nunique()
In [44]:
print ('DAU/WAU = ', "{:.2%}".format(dau/wau))
print ('DAU/MAU = ', "{:.2%}".format(dau/mau))
DAU/WAU =  20.50%
DAU/MAU =  6.81%

Для полной картины необходимо отследить данные за бОльший период и посмотреть как меняется это значение.

Результат DAU/MAU довольно низкий - всего 6%, пользователи не задерживаются в приложении

Исследуем на каких действиях пользователи проводили больше всего времени¶

Добавим в датасет столбец time_diff , который в рамках сессии считает сколько времени прошло с предыдущего действия

In [45]:
session['time_diff'] = session.groupby('session_id')['event_time'].diff()  

#поднимем значения в time_diff на одну ступень выше
session['time_diff'] = session['time_diff'].shift(-1) 
session.head(3)
Out[45]:
event_time event_name user_id session_id date week weekday time_diff
805 2019-10-07 13:39:45.989359 tips_show 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 1 2019-10-07 41 0 0 days 00:00:45.063550
806 2019-10-07 13:40:31.052909 tips_show 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 1 2019-10-07 41 0 0 days 00:00:34.669580
809 2019-10-07 13:41:05.722489 tips_show 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 1 2019-10-07 41 0 0 days 00:02:15.012972

Далее будем смотреть по двум пунктам

  • на каком действии пользователи проводили больше всего времени суммарно
  • какая медиана продолжительности для каждого шага (более точный показатель)
In [46]:
#выделим отдельным столбцом количество минут
session['time'] = session['time_diff'] / np.timedelta64(1, 's')
In [47]:
#для каждого действия посчитаем сколько времени на нем провели всего
time_event = pd.DataFrame(session.groupby('event_name')['time_diff'].sum()).sort_values(by='time_diff', ascending=False).reset_index()

#для каждого действия посчитаем какая медиана его продолжительности
time_event_median = pd.DataFrame(session.groupby('event_name')['time'].median()).sort_values(by='time', ascending=False).reset_index()
In [48]:
plt.figure(figsize=(15,14))

plt.subplot(2,1,1)
plt.bar(time_event['event_name'], time_event['time_diff'], color='#668567')
plt.xticks(rotation=15)
plt.title('Суммарное количество потраченного времени на каждое действие')
plt.ylabel('Время')
plt.xlabel('Действие')


plt.subplot(2,1,2)
plt.bar(time_event_median['event_name'], time_event_median['time'], color='#d9e9a9')
plt.title('Медиана потраченного времени на каждое действие')
plt.ylabel('Время')
plt.xlabel('Действие')
plt.xticks(rotation=15)


plt.show()

Большую часть своего времени пользователи тратят на просмотр рекомендованных объявлений (это самое часто повторяющееся событие).

Далее идут:

  • посмотрел фотографии в объявлении
  • посмотрел контакты
  • поиск (первый тип)
  • посмотрел карту

По медианному времени разрыв во времени меньше. События, на которых тратят больше всего времени:

  1. просмотр рекомендованных объявлений
  2. просмотр карты
  3. различные типы поисков (1,3,5)
  4. просмотр фото в объявлениях

Посмотрим с какого действия клиента начинает свое взаимодействие с приложением и каким заканчивает. Будем выводить датасет полностью, а не только первые строки, так можно сделать больше выводов

In [49]:
print('Действия, которые пользователь совершает первым делом в приложении ')
pd.DataFrame(session.pivot_table(index = 'session_id', values='event_name', aggfunc='first').value_counts())
Действия, которые пользователь совершает первым делом в приложении 
Out[49]:
0
event_name
tips_show 4092
photos_show 1775
search_1 1472
map 1325
show_contacts 609
search_4 529
advert_open 449
search_7 169
search_6 143
favorites_add 119
search_5 117
search_3 69
tips_click 56
search_2 51

Чаще всего клиенты своим первым действием видят рекомендованное объявление.

Также часто первым действием в сессии бывает просмотр фото/поиск/просмотр карты.

In [50]:
print('Действия, которые пользователь совершает перед выходом из приложения')
pd.DataFrame(session.pivot_table(index = 'session_id', values='event_name', aggfunc='last').value_counts())
Действия, которые пользователь совершает перед выходом из приложения
Out[50]:
0
event_name
tips_show 5681
photos_show 2191
search_1 849
show_contacts 811
map 351
advert_open 333
favorites_add 240
contacts_call 223
search_5 182
tips_click 55
search_3 42
search_4 9
search_6 4
search_7 3
search_2 1

Чаще всего последнее действие перед выходом - просмотр рекомендованного объявления или просмотр фото объявления.

Пользователи редко выходят из приложения после ряда поисков (2-7).

Стоит принять во внимание, что действия tips_show и photos_show часто встречаются, поэтому они и попали в топ обоих рейтигов.

Выводы по исследовательскому анализу данных:

  • данные предоставлены за период с 9 октября по 5 ноября
  • всего 4293 уникальных пользователей
  • на следующий день в приложение заходит около 10% пользователей, на 7 день остается около 5%. Показатели небольшие, пользователи редко заходят в приложение повторно
  • 75% пользователей за это время совершили до 6 сессий, медиана - 1
  • самое популярное действие - просмотр рекомендованного объявления
  • по источникам установки приложения статистика такая: 45% пользователей из Yandex, 26% из Google и 29% из других источников
  • медиана продолжительности сессии около 5 минут, в то время как среднее значение - почти 11 минут. Такая сильная разница из-за большого количества выбросов
  • медиана количества просмотров карточек за одну сессию - 3, а среднее - 4.8
  • медиана по количеству пользователей в день - 292, в неделю - 1427
  • в первую неделю (7-13 октября) зафиксирована низкая активность пользователей
  • пользователи чаще заходят в приложение в будние дни
  • Sticky Factor (липкость) равна 7%, пользователи не задерживаются в приложении
  • по медиане потраченного временни на действие следующие результаты.Дольше всего пользователи тратят на просмотр рекомендованных объявлений, просмотр карты, различные типы поисков (1,3,5) и просмотр фото в объявлении
  • первое и последнее действие в приложении - это обычно просмотр рекомендованного объявления и просмотр фото.Пользователи редко выходят из приложения после поиска (разного типа). Можно предположить, что они нашли необходимое объявление, поэтому остались в приложении.

Основная цель исследования ¶

Построим диаграмму Sankey для визуализации основных сценариев¶

Для построения диаграммы необходимо посмотреть сренее количество событий в сценарии.

Выделим список сценариев в рамках каждой сессии

In [51]:
def myfunc(column):
    """ функция возвращает список уникальных действий

    input: действия пользователя
    output: список уникальных действий пользователя
    """
    return column.unique()
In [52]:
#в рамках сессии выделим уникальные действия
pivot = session.pivot_table(index=['session_id'], values='event_name', aggfunc=myfunc).reset_index() 

#если в стобце толкько одно действие, то поменяем тип на list
pivot['event_name']  = pivot['event_name'].agg(lambda x: x if type(x) !=str  else x.split())

#уберем сценарии, состоящие из одного шага
pivot = pivot[pivot['event_name'].str.len() > 1]

pivot.sample(5)
Out[52]:
session_id event_name
10428 10429 [map, tips_show]
5891 5892 [map, tips_show]
7077 7078 [map, show_contacts, tips_show]
1760 1761 [show_contacts, tips_show]
6336 6337 [show_contacts, tips_show]

Посмотрим сколько действий в сценариях

In [54]:
#добавим столбец с количеством действий в сценарии
pivot['event_count'] = pivot['event_name'].apply( lambda x: len(x))

#запросим информацию по этому столбцу
pivot['event_count'].describe()
Out[54]:
count    5337.000000
mean        2.697396
std         0.998715
min         2.000000
25%         2.000000
50%         2.000000
75%         3.000000
max         9.000000
Name: event_count, dtype: float64

Медиана равна 2, среднее - 2.7. Максимальное количество действий в рамках одного сценария - 9. Учтем эту инaормацию при ограничении диаграммы в количестве шагов

Избавимся от повторяющихся событий в рамках сессии

In [55]:
session = session[~session[['event_name', 'session_id']].duplicated()]
session.head()
Out[55]:
event_time event_name user_id session_id date week weekday time_diff time
805 2019-10-07 13:39:45.989359 tips_show 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 1 2019-10-07 41 0 0 days 00:00:45.063550 45.063550
6541 2019-10-09 18:33:55.577963 map 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 2 2019-10-09 41 2 0 days 00:01:32.683012 92.683012
6565 2019-10-09 18:40:28.738785 tips_show 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 2 2019-10-09 41 2 0 days 00:01:54.225163 114.225163
36412 2019-10-21 19:52:30.778932 tips_show 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 3 2019-10-21 43 0 0 days 00:00:46.386077 46.386077
36419 2019-10-21 19:53:38.767230 map 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 3 2019-10-21 43 0 0 days 00:01:06.242629 66.242629

Сделаем копию таблицы session, так как для построения диаграммы придется ее видоизменить

In [56]:
session_1 = session.copy()

Подготовим таблицу

  • добавим столбцы source и target. Там в рамках сессии будет первое действие и последующее соответственно
  • в столбце step будет номер шага этой пары
  • уберем столбец event_name так как он больше не понадобится
In [57]:
def add_features(df):
    
    """ функция возвращает таблицу с новыми столбцами `source`, `targe`, `step` и убирает столбец `event_name`

    input: таблица
    output: таблица с новыми столбцами (источник, целевое действие, номер шага) и без столбца с названием действия
    """
    
    # сортируем по номеру сессии и времени
    sorted_df = df.sort_values(by=['session_id', 'event_time']).copy()
    
    # добавляем шаги событий
    sorted_df['step'] = sorted_df.groupby('session_id').cumcount() + 1
    
    # добавляем узлы-источники и целевые узлы
    # узлы-источники - это сами события
    sorted_df['source'] = sorted_df['event_name']
    
    # добавляем целевые узлы
    sorted_df['target'] = sorted_df.groupby('session_id')['source'].shift(-1)
    
    # возврат таблицы без имени событий
    return sorted_df.drop(['event_name'], axis=1)
  

session = add_features(session)
session.head()
Out[57]:
event_time user_id session_id date week weekday time_diff time step source target
805 2019-10-07 13:39:45.989359 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 1 2019-10-07 41 0 0 days 00:00:45.063550 45.063550 1 tips_show NaN
6541 2019-10-09 18:33:55.577963 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 2 2019-10-09 41 2 0 days 00:01:32.683012 92.683012 1 map tips_show
6565 2019-10-09 18:40:28.738785 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 2 2019-10-09 41 2 0 days 00:01:54.225163 114.225163 2 tips_show NaN
36412 2019-10-21 19:52:30.778932 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 3 2019-10-21 43 0 0 days 00:00:46.386077 46.386077 1 tips_show map
36419 2019-10-21 19:53:38.767230 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 3 2019-10-21 43 0 0 days 00:01:06.242629 66.242629 2 map NaN

Необходимо ограничить количество шагов. Выведем предыдущий расчет.

In [58]:
pivot['event_count'].describe()
Out[58]:
count    5337.000000
mean        2.697396
std         0.998715
min         2.000000
25%         2.000000
50%         2.000000
75%         3.000000
max         9.000000
Name: event_count, dtype: float64

Медиана по количеству действий в сценарии равна двум, а в 75% сценариев до 3 действий. Поэтому ограничим количество до 3

В df_comp запишем таблицу session, но удалим все пары source-target, шаг которых превышает 3

In [59]:
df_comp = session[session['step'] <= 3].copy().reset_index(drop=True)

Создадим словарь, в котором ключи - это шаги, а значения - словари со списком названий source и соответствующих им индексов.

Затем для каждого шага объединяем имена и индексы в еще один вложенный словарь

In [60]:
def get_source_index(df):
    
    """ функция генерирует индексы столбца `source`

    input: таблица
    output: словарь с индексами, именами и соответсвиями индексов именам `source`
    """
    
    res_dict = {}
    
    count = 0
    # получаем индексы источников
    for no, step in enumerate(df['step'].unique().tolist()):
        # получаем уникальные наименования для шага
        res_dict[no+1] = {}
        res_dict[no+1]['sources'] = df[df['step'] == step]['source'].unique().tolist()
        res_dict[no+1]['sources_index'] = []
        for i in range(len(res_dict[no+1]['sources'])):
            res_dict[no+1]['sources_index'].append(count)
            count += 1
            
    # соединим списки
    for key in res_dict:
        res_dict[key]['sources_dict'] = {}
        for name, no in zip(res_dict[key]['sources'], res_dict[key]['sources_index']):
            res_dict[key]['sources_dict'][name] = no
    return res_dict
  

# создаем словарь
source_indexes = get_source_index(df_comp)
In [61]:
def colors_for_sources(mode='custom'):
    
    """ функция генерирует цвета для диаграммы
    
    input: ничего (запрос на генерацию цветов)
    output: словарь с цветами, соответствующими каждому индексу
    """
    # словарь, в который сложим цвета в соответствии с индексом
    colors_dict = {}
    
            
        # присваиваем ранее подготовленные цвета
    colors = requests.get('https://raw.githubusercontent.com/rusantsovsv/senkey_tutorial/main/json/colors_senkey.json').json()
    for no, label in enumerate(df_comp['source'].unique()):
        colors_dict[label] = colors['custom_colors'][no]
            
    return colors_dict
  
  
# генерируем цвета из списка
colors_dict = colors_for_sources()

Создадим словарь с данными.Для отрисовки нужны следующие данные:

  • sources - список с индексами source;

  • targets - список с индексами target;

  • values - количество уникальных пользователей, совершивших переход между узлами source-target ("объем" потока между узлами);

  • labels - названия узлов;

  • colors_labels - цвет узлов;

  • link_color - цвет потоков между узлами;

  • link_text - дополнительная информация.

Следующие 2 функции помогут создать словарь этих списков

In [62]:
def percent_users(sources, targets, values):
    
    """
      функция рассчитывает уникальные id в процентах (для вывода в hover text каждого узла)
    
    input: список с индексами `source`, список с индексами `target`, список с "объемами" потоков `values`
    output: список с "объемами" потоков в процентах
    """
    
    # объединим источники и метки и найдем пары
    zip_lists = list(zip(sources, targets, values))
    
    new_list = []
    
    # подготовим список словарь с общим объемом трафика в узлах
    unique_dict = {}
    
    # проходим по каждому узлу
    for source, target, value in zip_lists:
        if source not in unique_dict:
            # находим все источники и считаем общий трафик
            unique_dict[source] = 0
            for sr, tg, vl in zip_lists:
                if sr == source:
                    unique_dict[source] += vl
                    
    # считаем проценты
    for source, target, value in zip_lists:
        new_list.append(round(100 * value / unique_dict[source], 1))
    
    return new_list
In [63]:
def lists_for_plot(source_indexes=source_indexes, colors=colors_dict, frac=10):
    
    """
    функция создает необходимые переменные списков и возвращает их в виде словаря
    
    input:словарь с именами и индексами source `source_indexes`, словарь с цветами source `colors`, 
        ограничение на минимальный "объем" между узлами `frac`
    output: словарь со списками, необходимыми для диаграммы

    """
    
    sources = []
    targets = []
    values = []
    labels = []
    link_color = []
    link_text = []

    # проходим по каждому шагу
    for step in tqdm(sorted(df_comp['step'].unique()), desc='Шаг'):
        if step + 1 not in source_indexes:
            continue

        # получаем индекс источника
        temp_dict_source = source_indexes[step]['sources_dict']

        # получаем индексы цели
        temp_dict_target = source_indexes[step+1]['sources_dict']

        # проходим по каждой возможной паре, считаем количество таких пар
        for source, index_source in tqdm(temp_dict_source.items()):
            for target, index_target in temp_dict_target.items():
                # делаем срез данных и считаем количество id            
                temp_df = df_comp[(df_comp['step'] == step)&(df_comp['source'] == source)&(df_comp['target'] == target)]
                value = len(temp_df)
                # проверяем минимальный объем потока и добавляем нужные данные
                if value > frac:
                    sources.append(index_source)
                    targets.append(index_target)
                    values.append(value)
                    # делаем поток прозрачным для лучшего отображения
                    link_color.append(colors[source].replace(', 1)', ', 0.2)'))
                    
    labels = []
    colors_labels = []
    for key in source_indexes:
        for name in source_indexes[key]['sources']:
            labels.append(name)
            colors_labels.append(colors[name])
            
    # посчитаем проценты всех потоков
    perc_values = percent_users(sources, targets, values)
    
    # добавим значения процентов для howertext
    link_text = []
    for perc in perc_values:
        link_text.append(f"{perc}%")
    
    # возвратим словарь с вложенными списками
    return {'sources': sources, 
            'targets': targets, 
            'values': values, 
            'labels': labels, 
            'colors_labels': colors_labels, 
            'link_color': link_color, 
            'link_text': link_text}
  

# создаем словарь
data_for_plot = lists_for_plot()
Шаг:   0%|          | 0/3 [00:00<?, ?it/s]
  0%|          | 0/14 [00:00<?, ?it/s]
  0%|          | 0/15 [00:00<?, ?it/s]
In [64]:
def plot_senkey_diagram(data_dict=data_for_plot):    
    
    """
    функция строит диаграмму Сенкей 
    
    input: словарь со списками данных для построения
    output: диаграммы Sankey
    """
    
    fig = go.Figure(data=[go.Sankey(
        domain = dict(
          x =  [0,1],
          y =  [0,1]
        ),
        orientation = "h",
        valueformat = ".0f",
        node = dict(
          pad = 50,
          thickness = 15,
          line = dict(color = "black", width = 0.1),
          label = data_dict['labels'],
          color = data_dict['colors_labels']
        ),
        link = dict(
          source = data_dict['sources'],
          target = data_dict['targets'],
          value = data_dict['values'],
          label = data_dict['link_text'],
          color = data_dict['link_color']
      ))])
    fig.update_layout(title_text="Sankey Diagram", font_size=10, width=800, height=600)
    
    # возвращаем объект диаграммы
    return fig
  

# сохраняем диаграмму в переменную
senkey_diagram = plot_senkey_diagram()
senkey_diagram.show()

Хоть мы и ограничили сценарии до трех шагов, диаграмма все равно получилась большая. По диаграмме можно сделать множество выводов, но вот некоторые из них:

  1. По первому source:
    • чаще всего, заходя в приложение, пользователь первым делом видит рекомендованноe объявление, далее по частоте идет просмотр карты и поиск первого типа
    • после поиска первого типа пользователи чаще всего переходят на просмотр фото объявления
    • после просмотра номера телефона пользователи чаще всего звонят по номеру
    • после просмотра фото в объявлении пользователи чаще всего смотрят номер телефона или ищут (первый тип поиска)
    • после открытия карточек объявления или просмотра карты пользователи чаще всего видит рекомендованные объявления
    • после просмотра рекомендованного объявления пользователи чаще всего смотрят номер телефона или карту
  2. По второму source:

    • после просмотра фото бОльшая часть людей уходят, остальные либо добавляют объявление в избранное, либо смотрят номер телефона
    • после того, как увидели рекомендованное объявление бОльшая часть людей уходят, остальные чаще всего смотрят номер телефона
    • после просмотра номера телефона бОльшая часть людей уходят, остальные чаще всего звонят по номеру
    • после поиска разного типа(2,3,4,6) почти все пользователи остаются в приложении
    • после просмотра карты около половины часть людей уходят, остальные чаще всего открывают карточки объявления
  3. Выводы по целевому действию - просмотр контактов

    • первым шагом к просмотру контактов приходят со следующих шагов (по убыванию количества пользователей):
      • увидел рекомендованные объявления (бОльшая часть)
      • просмотрел фотографий в объявлении
      • поиск первого типа
    • вторым шагом к просмотру контактов приходят со следующих шагов

      • увидел рекомендованные объявления (бОльшая часть)
      • просмотрел фотографий в объявлении
      • открыл карту объявлений
      • поиск первого типа

      Чаще всего к просмотру контактов приходят через самые популярные действия (просмотр рекомендаций и фото, поиск первого типа)

Найдем самые популярные сценарии использования приложения, которые приводят к целевому действию¶

Целевым действием посчтаем просмотр контактов show_contacts, так как именно это действие показывает заинтересованность пользователя объявлением

In [65]:
pivot.sample(3)
Out[65]:
session_id event_name event_count
2196 2197 [search_4, search_5, tips_show] 3
5588 5589 [search_1, photos_show] 2
7713 7714 [search_5, tips_show] 2
In [66]:
#раскроем список действий
pivot['event_name']  = pivot['event_name'].agg(lambda x: ', '.join(x))

pivot.head(5)
Out[66]:
session_id event_name event_count
1 2 map, tips_show 2
2 3 tips_show, map 2
3 4 map, tips_show 2
4 5 search_1, photos_show 2
5 6 search_1, photos_show, favorites_add, show_con... 5
In [67]:
#раскроем список действий
pivot['event_name']  = pivot['event_name'].agg(lambda x: ', '.join(x) if type(x) !=str  else x )

pivot.head(5)
Out[67]:
session_id event_name event_count
1 2 map, tips_show 2
2 3 tips_show, map 2
3 4 map, tips_show 2
4 5 search_1, photos_show 2
5 6 search_1, photos_show, favorites_add, show_con... 5

Найдем самые популярные сценарии, которые приводят к целевому действию

In [68]:
#gосчитаем сколько раз повторяется каждый сценарий
scenario = pd.DataFrame(pivot.groupby('event_name')['session_id'].count()).reset_index() 

#уберем сценарии, в которых нет целевого действия
scenario = scenario.loc[scenario['event_name'].str.contains('show_contacts')]

#уберем сценарии, которые начинаются с целевого дейтсвия (так как задача посмотреть какие действия приводят к целевому действию)
scenario = scenario.loc[~scenario['event_name'].str.startswith('show_contacts')]

#остортируем по убыванию популярности сценариев
scenario = scenario.sort_values(by='session_id', ascending=False).reset_index()

scenario.head(5)
Out[68]:
index event_name session_id
0 639 tips_show, show_contacts 376
1 151 map, tips_show, show_contacts 93
2 180 photos_show, show_contacts 83
3 217 search_1, show_contacts, contacts_call 52
4 216 search_1, show_contacts 44

Пользователи проходят разные шаги перед открытием номера телефона, но вот самые популярные сценарии:

  • увидел рекомендованные объявления, посмотрел номер телефона
  • открыл карту объявлений, увидел рекомендованные объявления, посмотрел номер телефона
  • просмотрел фотографий в объявлении, посмотрел номер телефона
  • поиск по сайту, посмотрел номер телефона, позвонил по номеру
  • поиск по сайту, посмотрел номер телефона

Построим воронки по популярным сценариям¶

Продублируем четыре самых популярных сценария

In [69]:
scenario.head(4)
Out[69]:
index event_name session_id
0 639 tips_show, show_contacts 376
1 151 map, tips_show, show_contacts 93
2 180 photos_show, show_contacts 83
3 217 search_1, show_contacts, contacts_call 52

Визуализируем сценарии в виде воронок

In [70]:
for i in range(4):
    scenario_list = scenario.loc[i, 'event_name'].split(', ') #преобразовывает колонку `event_name` в список действий
    print(f'Воронка по сценарию {" - ".join(scenario_list)}. По такому пути пользователи прошли {scenario["session_id"][i]} раз')

    sp=[] #подготовим пустой список
    
    df = session_1.copy() #запишем в df полную таблицу 
    
    #для каждого действия в сценарии
    for e in scenario_list: 
        
        # в `users` запишем id пользователей, кто совершал это действие
        users = df.query('event_name == @e')['user_id']
        
        # `df` перезапишем и оставим там только тех пользователей, чьи id в переменной `users`
        df = df.query('user_id in @users') 
        
        # количество оставшихся уникальных пользователей запиешем в список
        sp.append(df['user_id'].nunique())

    #построим воронку
    fig = go.Figure(go.Funnel(
    y = scenario_list,
    x =  sp,
    textposition = "inside",
    textinfo = "value+percent initial",
    marker = {"color":'forestgreen'}))
    
    fig.show()
Воронка по сценарию tips_show - show_contacts. По такому пути пользователи прошли 376 раз
Воронка по сценарию map - tips_show - show_contacts. По такому пути пользователи прошли 93 раз
Воронка по сценарию photos_show - show_contacts. По такому пути пользователи прошли 83 раз
Воронка по сценарию search_1 - show_contacts - contacts_call. По такому пути пользователи прошли 52 раз

Воронки получились небольшие - на 2-3 шага. Посмотрим на каждую из них.

  1. Увидел рекомендованные объявления - посмотрел номер телефона.

    • такой путь проходили пользователи чаще всего, причем в 4 раза чаще, чем по второму популярному сценарию.
    • с просмотра рекомендованных объявлений до просмотора номера телефона перешел примерно каждый пятый пользователь (18%)
  1. Открыл карту объявлений - увидел рекомендованные объявления - посмотрел номер телефона

    • 92% пользователей с просмотра карты перешли на просмотр рекомендаций
    • 20% пользователей после просмотра рекомендаций посмотрели номер телефона
    • в итоге с первого шага до последнего дошло 19% пользователей
  1. Просмотрел фотографий в объявлении - посмотрел номер телефона
    • почти каждый третий пользователь (31%) после просмотра фотографий посмотрел номер телефона
  1. Поиск по сайту (первый тип) - посмотрел номер телефона - позвонил по номеру из объявления
    • почти каждый третий пользователь (30%) после поиска первого типа посмотрел номер телефона
    • почти половина (47%) пользователей после просмотра номера телефона позвонили по нему
    • в итоге с первого шага до последнего дошло 15% пользователей

Выводы, основанные на анализе 4 воронок:

  • очень хорошая конверсия с просмотра карты на просмотр рекомендованых объявлений (92%)
  • на просмотр номера телефона с предыдущего шага переходило 18-31% пользователей:
    • 18% и 20% (первые два сценария) с просмотра рекомендаций
    • 31% с просмотра фотографий
    • 30% с поиска первого типа

Проанализируем как различается время между поиском и открытием объявления у пользователей, совершающих и не совершающих целевое действие (просмотр контакта)¶

Будем исследовать время между search_1 (самое часто повторяющееся действие поиска) и advert_open

Разделим пользователей на две группы

users_sc - группа с пользователями, которые посмотрели контакты

users_without_sc- группа с пользователями, которые не посмотрели контакты

In [71]:
# создадим список пользователей, которые просмотрели контакты
show_contacts = session_1.query('event_name == "show_contacts"')['user_id'].unique() 

#оставим пользователей, чьи id есть в `show_contacts`
users_sc = session_1.query('user_id in @show_contacts ') 

#оставим пользователей, чьих id нет в `show_contacts`
users_without_sc = session_1.query('user_id not in @show_contacts')

Найдем медианное значение времени между поиском и открытием у первой группы (тех, кто просмотрел контакты)

In [72]:
mean, median = [],[]
for e in [users_sc, users_without_sc]:
    #список сессий, в которых есть поиск
    search = e.query('event_name == "search_1"')['session_id'] 

    #список сессий, в которых есть открытие
    adv_open = e.query('event_name == "advert_open"')['session_id']

    #cписок сессий, в рамках которых есть и поиск и открытие объявления
    session_list = list(set(search)&set(adv_open))  

    # оставим строки  только с сессиями из `session_sc`
    df = e.query('session_id in @session_list') 

    #оставим только два действия (поиск и открытие)
    df = df.query('event_name in ["search_1", "advert_open"]') 

    #найдем разницу между действиями в рамках одной сессии
    df['time'] = df.groupby('session_id')['event_time'].diff()

    #посчитаем медианное значение времени
    df_mean = df['time'].mean()
    df_median = df['time'].median()
    
    mean.append(df_mean)
    median.append(df_median)
In [73]:
print('Среднее время поиска', mean)
print('Медиана времени поиска', median)
Среднее время поиска [Timedelta('0 days 00:10:16.597706166'), Timedelta('0 days 00:13:52.897521047')]
Медиана времени поиска [Timedelta('0 days 00:07:27.938930'), Timedelta('0 days 00:10:10.340973')]

У первой группы (пользователей, просмотревших контакты) среднее время поиска составляет 10 минут 17 секунд, медиана - 13 минут 52 секунды.

У второй группы (пользователей, не просмотревших контакты) среднее время поиска составляет 7 минут 28 секунд, медиана - 10 минут 10 секунд.

In [74]:
print(f'Разница медиан длительности времени между поиском и открытием объявления составляет {median[1] - median[0]}')
Разница медиан длительности времени между поиском и открытием объявления составляет 0 days 00:02:42.402043

Пользователи, которые смотрят контакты тратят меньше времени на поиск. Как правило, на поиск перед открытием объявления они тратят на 2.42 минуты меньше чем те, кто не смотрит контакты.

Такую закономерность можно поробовать обосновать. Возможно, что долгий поиск обуславливается отсутсвием нужных предложений, как следствие отсутсвие сильного интереса к найденному объявлению

Выводы по разделу:

  • медиана по количеству действий в рамках сценария равна двум
  • пользователи смотрят контакты чаще всего после просмотра рекомендованных объявлений или фотографий в объявлении
  • самые популярные сценарии такие:
    • увидел рекомендованные объявления, посмотрел номер телефона
    • открыл карту объявлений, увидел рекомендованные объявления, посмотрел номер телефона
    • просмотрел фотографий в объявлении, посмотрел номер телефона
    • поиск по сайту, посмотрел номер телефона, позвонил по номеру
  • чаще всего на популярных сценариях конверсия в просмотр контакты с предыдущего шага составляет 20-30%
  • Время между поиском и открытием объявления у пользователей, совершающих и не совершающих действие просмотр контакта отличатеся. Пользователи, которые смотрят контакты тратят меньше времени на поиск (на 2.42 минуты).

Проверка гипотез ¶

Выдвинем и проверим три гипотезы

  1. Одни пользователи совершают действия tips_show и tips_click, другие — только tips_show. Гипотеза: конверсия в просмотры контактов различается у этих двух групп.
  2. Конверсия в просмотры контактов у пользователей с действием favorites_add иная, чем у пользователей, не совершивших это действие
  3. Есть люди, установившие приложение с Yandex, а есть, кто с Google. Гипотеза: конверсия в просмотры контактов различается у этих двух групп.

Напишем функцию для работы с гипотезами. Для всех гипотез будем использовать z-test (о равенстве долей)

In [75]:
def hypothesis_testing(trials, successes):
    
    """
    функция выводит значение p-value и выводы о принятии/отвержении гипотезы
    
    input: список с общим количеством пользователей в каждой из групп,
           список с количеством пользователей, совершивших целевое действие в каждой из групп,
    output: значение p-value и выводы о принятии/отвержении гипотезы
    """
    
    # зададим критический уровень статистической значимости
    alpha = .05 

    # пропорция успехов в первой группе:
    p1 = successes[0]/trials[0]

    # пропорция успехов во второй группе:
    p2 = successes[1]/trials[1]

    # пропорция успехов в комбинированном датасете:
    p_combined = (successes[0] + successes[1]) / (trials[0] + trials[1])

    # разница пропорций в датасетах
    difference = p1 - p2

    # считаем статистику в ст.отклонениях стандартного нормального распределения
    z_value = difference / mth.sqrt(p_combined * (1 - p_combined) * (1/trials[0] + 1/trials[1]))

    # задаем стандартное нормальное распределение (среднее 0, ст.отклонение 1)
    distr = st.norm(0, 1) 

    p_value = (1 - distr.cdf(abs(z_value))) * 2

    print('p-значение: ', p_value)

    #выводим вывод
    if p_value < alpha:
        print('Отвергаем нулевую гипотезу: между долями есть значимая разница')
    else:
        print('Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными')

Проверим первую гипотезу¶

Если клиенту попадается рекомендованное объявление, и он переходит по нему, значит объявление было верно подобрано и пользователь заинтересован. Интересено узнать, в таком случае будет ли конверсия в просмотр контакты выше по сравнению с тем пользователями, которые не переходят по рекомедованному оъявлению

Первая группа пользователей совершают действия tips_show и tips_click (увидел рекомендованные объявления и кликнул по нему)

Вторая группа пользователей только tips_show (увидел рекомендованное объявление, но не кликнул по нему)

Нулевая гипотеза: конверсия в просмотры контактов не различается у этих двух групп

Альтернативная гипотеза: между конверсией в просмотры контактов у этих двух групп есть статистически значимая разница

Создадим сводную таблицу и для каждого пользователя выведем список его уникальных действий, сохраним в перменной event_for_users

In [76]:
event_for_users = session_1.groupby('user_id')['event_name'].unique().reset_index()
event_for_users['event_name'] = event_for_users['event_name'].agg(lambda x: ' '.join(x))
event_for_users.head(3)
Out[76]:
user_id event_name
0 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 tips_show map
1 00157779-810c-4498-9e05-a1e9e3cedf93 search_1 photos_show favorites_add show_contac...
2 00463033-5717-4bf1-91b4-09183923b9df photos_show

Для каждой из групп найдем:

  • количество всех уникальных пользователей в группе
  • количество уникальных пользователей в группе, посмотревших контакты
In [77]:
#в `group_1` сохраним id только тех пользователей, кто совершил `tips_show` и `tips_click`
group_1 = event_for_users.loc[event_for_users['event_name'].str.contains("tips_show") & event_for_users['event_name'].str.contains("tips_click")]

#посчитаем количество уникальных пользователей 
gr_1 = group_1['user_id'].nunique()

print(gr_1, 'пользователей в первой группе')
297 пользователей в первой группе
In [78]:
#посчитаем количество уникальных пользователей из группы 1, которые посмотрели контакты
gr_1_sc = group_1.loc[group_1['event_name'].str.contains("show_contacts")]['user_id'].nunique()

print(gr_1_sc, 'пользователей из первой группы посмотрели контакты' )
91 пользователей из первой группы посмотрели контакты
In [79]:
#в `group_2` сохраним id только тех пользователей, кто совершил `tips_show`, но не совершил `tips_click`
group_2 = event_for_users.loc[event_for_users['event_name'].str.contains("tips_show") & ~(event_for_users['event_name'].str.contains("tips_click"))]

#посчитаем количество уникальных пользователей
gr_2 = group_2['user_id'].nunique() 

print(gr_2, 'пользователей во второй группе')
2504 пользователей во второй группе
In [80]:
#посчитаем количество уникальных пользователей из группы 2, которые посмотрели контакты
gr_2_sc = group_2.loc[group_2['event_name'].str.contains("show_contacts")]['user_id'].nunique()

print(gr_2_sc, 'пользователей из второй группы посмотрели контакты')
425 пользователей из второй группы посмотрели контакты
In [81]:
print(f'''
{gr_1} пользователей увидели рекомендованное объявление и перешли по нему.
Из них {gr_1_sc} посмотрели номер телефона.

{gr_2} пользователь увидели рекомендованное объявление, но не перешли по нему.
Из них {gr_2_sc} посмотрели номер телефона.
''')
297 пользователей увидели рекомендованное объявление и перешли по нему.
Из них 91 посмотрели номер телефона.

2504 пользователь увидели рекомендованное объявление, но не перешли по нему.
Из них 425 посмотрели номер телефона.

In [82]:
#передадим полученные значения функции
successes = [gr_1_sc, gr_2_sc]
trials = [gr_1, gr_2]
hypothesis_testing(trials, successes)
p-значение:  9.218316554537864e-09
Отвергаем нулевую гипотезу: между долями есть значимая разница

Между долями есть значимая разница. Как ни странно, но у пользователей, которые не перходили по рекомендованному объявлению конверсия в просмотр контактов выше.

Проверим вторую гипотезу¶

Проверим, выше ли конверсия в просмотр контактов у пользователей, которые добавляют объявления в избранное (действие favorites_add)

Нулевая гипотеза: конверсия в просмотр контактов не различается у пользователей, добавляющих объявления в избранное и не делающих это

Альтернативная гипотеза: между конверсией в просмотр контактов у пользователей, добавляющих объявления в избранное и не делающих это есть статистически значимая разница

Для каждой из групп найдем:

  • количество всех уникальных пользователей в группе
  • количество уникальных пользователей в группе, посмотревших контакты
In [83]:
# создадим список пользователей кто добавлял объявления в избранное
fa = session_1.query('event_name == "favorites_add"')['user_id'] # список пользователей кто добавлял объявления в избранное

# создадим список пользователей кто не добавлял объявления в избранное
nfa = session_1.query('user_id not in @fa')['user_id'] 

#посчитаем количество уникальных пользователей в каждой из групп
fa_count = fa.nunique() 
nfa_count = nfa.nunique() 

print(f'{fa_count} пользователей добавляли объявления в избранное, а {nfa_count} не совершали это действие')
351 пользователей добавляли объявления в избранное, а 3942 не совершали это действие
In [84]:
#датасет с событием "посмотрел контакт"
show_cont = session_1.query('event_name == "show_contacts"') 

#для каждой группы посчитаем сколько пользователей посмотрели контакты
sc_fa = show_cont.query('user_id in @fa')['user_id'].nunique()
sc_nfa = show_cont.query('user_id in @nfa')['user_id'].nunique()

print(f'Из первой группы посмотрели контакты {sc_fa} пользователей, из второй - {sc_nfa}')
Из первой группы посмотрели контакты 136 пользователей, из второй - 845
In [85]:
#передадим полученные значения функции
successes = [sc_fa, sc_nfa]
trials = [fa_count, nfa_count]
hypothesis_testing(trials, successes)
p-значение:  1.3455903058456897e-13
Отвергаем нулевую гипотезу: между долями есть значимая разница

Между конверсией в просмотр контактов у пользователей, добавляющих объявления в избранное и не делающих это есть статистически значимая разница.

Пользователи, которые добавляют объявление в избранное чаще просматривают номера телефонов.

Проверим третью гипотезу¶

Для продвижения сервиса необходимо выяснить, есть ли разница между пользователями и их поведением в зависимости от источника, с которого они установили приложения

Нулевая гипотеза: конверсия в просмотры контактов не различается у пользователей, установивших приложения с Yandex и установивших с Google

Альтернативная гипотеза: между конверсией в просмотры контактов у пользователей, установивших приложения с Yandex и установивших с Google есть статистически значимая разница

Для каждой из групп найдем:

  • количество всех уникальных пользователей в группе
  • количество уникальных пользователей в группе, посмотревших контакты
In [86]:
 #объеденим два датафрейма
common = session_1.merge(sources, on='user_id')

common.head(3)
Out[86]:
event_time event_name user_id session_id date week weekday time_diff time source
0 2019-10-07 13:39:45.989359 tips_show 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 1 2019-10-07 41 0 0 days 00:00:45.063550 45.063550 other
1 2019-10-09 18:33:55.577963 map 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 2 2019-10-09 41 2 0 days 00:01:32.683012 92.683012 other
2 2019-10-09 18:40:28.738785 tips_show 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 2 2019-10-09 41 2 0 days 00:01:54.225163 114.225163 other
In [87]:
#для каждого источника посчитаем количество уникальных пользователей
source = common.pivot_table(index='source', values='user_id', aggfunc='nunique')

display(source)

#для каждый группы запишем и  выведем значение
yandex = source.loc['yandex', 'user_id']
google = source.loc['google', 'user_id']

print(f'''
Из Yandex установило {yandex} пользователей
Из Google - {google}''')
user_id
source
google 1129
other 1230
yandex 1934
Из Yandex установило 1934 пользователей
Из Google - 1129
In [88]:
#оставим только строки с действием `show_contacts`
common = common.loc[common['event_name'] == "show_contacts"] 

#подготовим пустой список
successes = []

for e in ['yandex', 'google']:
    
    #оставим в таблице только пользователей из источника
    df = common.query('source == @e') 
    
    #посчитаем количество уникальных пользователей, посмотревших контакты
    successes.append(df['user_id'].nunique())
In [89]:
#оставим только строки с действием `show_contacts`
common = common.loc[common['event_name'] == "show_contacts"] 

#подготовим пустой список
successes = []

for e in ['yandex', 'google']:
    
    #в список добавим пользователей из данного источника, посчитаем их колиество
    successes.append(common.query('source == @e')['user_id'].nunique())
                     
print(f'Контакты посмотрели {successes[0]} пользователей из Yandex и {successes[1]} из Google')
Контакты посмотрели 478 пользователей из Yandex и 275 из Google
In [90]:
#передадим полученные значения функции
successes = successes
trials = [yandex, google]
hypothesis_testing(trials, successes)
p-значение:  0.8244316027993777
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными

Конверсия в просмотры контактов не различается у пользователей, установивших приложения с Yandex и установивших с Google

Выводы по гипотезам

  1. Между конверсией в просмотры контактов у первой (пользователи, которые увидели рекомендованные объявление и перешли по ним) и второй (пользователи, которые увидели рекомендованные объявление, но не перешли по ним) групп есть статистически значимая разница.

У пользователей, которые не перходили по рекомендованному объявлению конверсия в просмотр контактов выше. Выборка не очень большая и сложно делать выводы, основываясь только на этих данных.

Рекомендация: провести аналогичное исследование, используя данные за бОльший период (например, пол года)

  1. Между конверсией в просмотр контактов у пользователей, добавляющих объявления в избранное и не делающих это есть статистически значимая разница.

Пользователи, которые добавляют объявление в избранное чаще просматривают номера телефонов. Выборка не очень большая, но все равно показательная. Действительно, добавление объявления в избранное показывает заинтересованность пользователя.

  1. Конверсия в просмотры контактов не различается у пользователей, установивших приложения с Yandex и установивших с Google.

Поведение пользователей, установивших приложение с этих двух разных источников идентично, по крайней мере по конверсии просмотра контактов.

Общий вывод¶

Было проанализировано поведение 4293 уникальных пользователей за период с 9 октября по 5 ноября 2019 года

По источникам установки приложения статистика такая: 45% пользователей из Yandex, 26% из Google и 29% из других источников

Для анализа мы разделили действия пользователей на сессии, временной ориентир - между действиями прошло не больше 20 минут:

  • 75% пользователей за это время совершили до 6 сессий, медиана - 1
  • медиана продолжительности сессии около 5 минут, в то время как среднее значение - почти 11 минут. Такая сильная разница из-за большого количества выбросов в большую сторону
  • за одну сессию в среднем пользователь успевал посмотреть 4.8 карточек, медиана - 3

Активность пользователей:

  • пользователи чаще активны в дневное время суток в рабочие дни - в первую неделю (7-13 октября) зафиксирована низкая активность пользователей
  • по показателю удержания - на следующий день в приложение заходит около 10% пользователей, на 7 день остается около 5%. Показатели небольшие, пользователи редко заходят в приложение повторно
  • медиана по количеству пользователей в день - 292, в неделю - 1427
  • Sticky Factor (липкость) равна 7%, пользователи не задерживаются в приложении

Действия пользователей:

  • самые частые действия - увидел рекомендованные объявления, посмотрел фотографии
  • больше всего времени пользователи тратя на просмотр рекомендованных объявлений, просмотр карты, различные типы поисков и просмотр фото в объявлении

Исследование основных вопросов:

  • обычно в рамках сессии, которая приводит к просмотру контакта пользователи совершают два действия. Популярные сценарии:
    • увидел рекомендованные объявления, посмотрел номер телефона
    • открыл карту объявлений, увидел рекомендованные объявления, посмотрел номер телефона
    • просмотрел фотографий в объявлении, посмотрел номер телефона
    • поиск по сайту, посмотрел номер телефона, позвонил по номеру
  • чаще всего на популярных сценариях конверсия в просмотр контакты с предыдущего шага составляет 20-30%
  • время между поиском и открытием объявления у пользователей, совершающих и не совершающих действие просмотр контакта отличается. Пользователи, которые смотрят контакты тратят меньше времени на поиск (на 2.42 минуты).

Выводы по гипотезам:

  • у пользователей, которые видели рекомендованные объявления, но не переходили по нему конверсия в просмотр контактов выше, чем у тех, кто увидел и перешел
  • пользователи, которые добавляют объявление в избранное чаще просматривают номера телефонов.Добавление объявления в избранное показывает заинтересованность пользователя.
  • конверсия в просмотр контакта у пользователей, установивших приложения с Yandex и Google похожа, значимой разницы нет.

Наблюдения и рекомендации¶

Были предоставлены данные за небольшой промежуток времени, тяжело сделать по ним какие-либо выводы, необходимо предоставить данные за бОльший промежуток времени с целью анализа динамики и сравнения показателей.

  1. Хочется выделить поверхностное взаимодействие пользователей с приложением:
    • у пользователей медиана количества сессий - 1 (за 28 дней)
    • sticky factor равен 7%
    • пользователи проводят в приложении 5-10 минут
    • за одну сессию просматривают 3-5 карточек
    • в рамках сессии совершает 2-3 действия
    • согласно Retention Rate только каждый десятый пользователь заходит на следующий день в приложение, и каждый двадцатый на седьмой день

Пользователи не заинтересованы, необходимо улучшить пользовательский опыт, удержать его в приложении

Пользователь проводит очень мало времени в приложении, совершает небольшое количество действий. Значит необходимо сделать это небольшое пребывание в приложении максимально комфортным, пользователь не будет долго разбираться в интерфейсе. Необходимо максимально укоротить путь клиента от входа в приложение до просмотра контакта. В этом может помочь грамотная рекомендация объявлений.

  1. Действие "увидел рекомендованные объявления" самое популярное. Пользователи чаще всего начинают и заканчивают свое взаимодействие этим действием, после него же чаще всего пользователи смотрят контакты. Подбирать и показывать объявления пользователем - отличный инструмент, если им правильно воспользоваться. В данной ситуации очень важно чтобы объявления соответствовали запросам клиента.

  2. Время между поиском и открытием объявления у пользователей, которые в итоге посмотрели контакты в объявлении меньше примерно на 2.40 минут по сравнению с теми пользователями, кто не посмотрел контакты. То есть посмотрел контакты тот, кто нашел объявление быстрее. Если поиск удобен и грамотно предлагает пользователю необходимую информацию, то скорее всего выданное объявление заинтересует пользователя, и он в итоге посмотрит контакты. Необходимо улучшать поисковую систему чтобы пользователь мог комфортнее и быстрее находить нужную информацию/нужные объявления

  1. Гипотеза подтвердилась - пользователи, которые добавляют объявление в избранное чаще просматривают номера телефонов. Действие "добавил в избранное" показывает заинтересованность, а значит и скорее всего пользователь посмотрит номер контакта. Возможно стоит обратить внимание на эту кнопку - насколько удобно она расположена, видно ли ее, как выглядит место, куда сохраняются объявления. Можно рассмотреть вариант предлагать пользователю сохранить объявление, если он на протяжении 20 секунд смотрит фотографии или карту в этом объявлении.

  2. По результатам проверки гипотезы у пользователей, которые видели рекомендованные объявления, но не переходили по нему конверсия в просмотр контактов выше, чем у тех, кто увидел и перешел. Можно предположить, что дело в некачественно подобранных рекомендованных объявлениях, которые были предложены пользователю.